import type { Metadata } from 'next'; import { notFound } from 'next/navigation'; import { Suspense } from 'react'; import { Tabs } from '@/components/ui/tabs'; import { Skeleton } from '@/components/ui/primitives'; import { AssetHeader } from '@/components/asset/asset-header'; import { ASSET_TABS, AnalysisTab, AuctionsTab, ComparablesTab, GradesTab, HistoryTab, ImagesTab, ListingsTab, OverviewTab, PopulationTab, SalesTab, SourcesTab, type AssetTab } from '@/components/asset/asset-tabs'; import { countAssetLots } from '@/lib/queries/market-lists'; import { getAssetBySlug, getAssetVariants, getAssetListings, getAssetLiveCounts, getLatestGuidePrice, getAssetImages, getPopulation, isWatchedBy } from '@/lib/queries/assets'; import { getCurrentUser } from '@/lib/auth/session'; import { catName, categoryPath } from '@/lib/taxonomy'; import { sp1, spEnum, spInt, type SP } from '@/lib/search-params'; import { fmtMoney } from '@/lib/format'; export const revalidate = 120; const SITE = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://www.rareindex.io'; export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise { const { slug } = await params; const a = await getAssetBySlug(slug); if (!a) return { title: 'Asset not found' }; const guide = a.rivUsd === null && a.latestSaleUsd === null ? await getLatestGuidePrice(a.id) : null; const price = a.rivUsd !== null ? ` · RIV ${fmtMoney(a.rivUsd)}` : a.latestSaleUsd !== null ? ` · last sale ${fmtMoney(a.latestSaleUsd)}` : guide ? ` · guide price ${fmtMoney(guide.priceUsd)} (${guide.sourceName})` : ''; const description = `${a.title}${price}. ${catName(a.categorySlug)} price history, verified sales, live listings, rarity and liquidity on RareIndex.`; return { title: a.title, description, alternates: { canonical: `${SITE}/asset/${a.slug}` }, openGraph: { title: a.title, description, url: `${SITE}/asset/${a.slug}`, images: [{ url: `/asset/${a.slug}/opengraph-image`, width: 1200, height: 630 }] }, twitter: { card: 'summary_large_image', title: a.title, description }, }; } export default async function AssetPage({ params, searchParams }: { params: Promise<{ slug: string }>; searchParams: Promise }) { const { slug } = await params; const sp = await searchParams; const found = await getAssetBySlug(slug); if (!found) notFound(); const tab = spEnum(sp, 'tab', ASSET_TABS, 'overview'); const [variants, live, imgs, user, popReports, lotCount] = await Promise.all([getAssetVariants(found.id), getAssetLiveCounts(found.id), getAssetImages(found.id, 12), getCurrentUser().catch(() => null), getPopulation(found.id), countAssetLots(found.id)]); // latest published population total across graders (for the rarity explainer); null when no report exists const population = popReports.length ? popReports.reduce((a, r) => (r.reportDate > a.reportDate ? r : a)).total : null; const watched = await isWatchedBy(user?.id ?? null, found.id); // asset_stats is rebuilt by the valuation worker; until then fall back to live counts from canonical tables. const asset = { ...found, salesCount: Math.max(found.salesCount, live.sales), sales30d: Math.max(found.sales30d, live.sales30d), activeListings: Math.max(found.activeListings, live.listings), sourcesCount: Math.max(found.sourcesCount, live.sources), latestSaleUsd: found.latestSaleUsd ?? live.latestSaleUsd, latestSaleAt: found.latestSaleAt ?? live.latestSaleAt, minAskUsd: found.minAskUsd ?? live.minAskUsd, }; const vParam = sp1(sp, 'v'); const variant = vParam ? variants.find((v) => v.id === vParam) ?? null : null; const page = spInt(sp, 'page'); const href = (t: AssetTab, v: string | null = variant?.id ?? null) => `/asset/${asset.slug}?tab=${t}${v ? `&v=${v}` : ''}`; const [listingsForLd, guide] = await Promise.all([getAssetListings(asset.id, { limit: 20 }), (variant ? variant.rivUsd : asset.rivUsd) === null ? getLatestGuidePrice(asset.id, variant?.id ?? null) : Promise.resolve(null)]); const crumbs = categoryPath(asset.categorySlug); const seen = new Set(); const images = [asset.heroImageUrl ? { url: asset.heroImageUrl, caption: null } : null, ...imgs.map((im) => ({ url: im.url, caption: im.attribution ?? im.sourceId ?? null }))] .filter((im): im is { url: string; caption: string | null } => Boolean(im)) .filter((im) => (seen.has(im.url) ? false : (seen.add(im.url), true))); const jsonLd = [ { '@context': 'https://schema.org', '@type': 'Product', name: asset.title, image: images.slice(0, 5).map((i) => i.url), description: asset.description ?? `${asset.title} — ${catName(asset.categorySlug)} collectible tracked by RareIndex.`, brand: asset.brand ? { '@type': 'Brand', name: asset.brand } : undefined, category: crumbs.map((c) => c.name).join(' > '), productID: asset.id, sku: asset.identifiers.sku ?? asset.identifiers.upc ?? undefined, url: `${SITE}/asset/${asset.slug}`, ...(listingsForLd.length && listingsForLd.some((l) => l.priceUsd !== null) ? { offers: { '@type': 'AggregateOffer', priceCurrency: 'USD', lowPrice: Math.min(...listingsForLd.filter((l) => l.priceUsd !== null).map((l) => l.priceUsd!)), highPrice: Math.max(...listingsForLd.filter((l) => l.priceUsd !== null).map((l) => l.priceUsd!)), offerCount: listingsForLd.length, offers: listingsForLd.slice(0, 5).map((l) => ({ '@type': 'Offer', price: l.priceUsd, priceCurrency: 'USD', url: l.sourceUrl, availability: 'https://schema.org/InStock', seller: l.seller ? { '@type': 'Organization', name: l.seller } : undefined })), }, } : {}), }, { '@context': 'https://schema.org', '@type': 'BreadcrumbList', itemListElement: [{ '@type': 'ListItem', position: 1, name: 'Markets', item: `${SITE}/markets` }, ...crumbs.map((c, i) => ({ '@type': 'ListItem', position: i + 2, name: c.name, item: `${SITE}/markets/${c.slug}` })), { '@type': 'ListItem', position: crumbs.length + 2, name: asset.title, item: `${SITE}/asset/${asset.slug}` }], }, ]; return (